Skip to content

Per-catchment CFE DA: true EnKF (N=20) + Vrugt R + mass-conserving cascade - #7

Draft
sonalivyascse19-stack wants to merge 122 commits into
NWC-CUAHSI-Summer-Institute:masterfrom
DualEarth:feature/da-v2-enkf
Draft

Per-catchment CFE DA: true EnKF (N=20) + Vrugt R + mass-conserving cascade#7
sonalivyascse19-stack wants to merge 122 commits into
NWC-CUAHSI-Summer-Institute:masterfrom
DualEarth:feature/da-v2-enkf

Conversation

@sonalivyascse19-stack

@sonalivyascse19-stack sonalivyascse19-stack commented May 14, 2026

Copy link
Copy Markdown

Summary

Data-assimilation script for the multi-catchment CFE Helene experiment:
Stochastic Ensemble Kalman Filter (Burgers / van Leeuwen / Evensen 1998) with
20 perturbed CFE realizations per catchment, Vrugt et al. 2005 (SODA)
heteroscedastic observation error scaled by the kriging variance
, per-state
process noise to prevent ensemble collapse, and a mass-conserving overflow/
underflow cascade.

The script takes pre-calibrated CFE parameters (loaded from <cat-id>_best_params.json)
and runs the test period with the EnKF on top. CFE parameter calibration itself
is done separately by the calibrate-cfe workflow; this script only corrects
states, not parameters.

Updates 4 CFE states per ensemble member every hour:
soil_reservoir["storage_m"], gw_reservoir["storage_m"], nash_storage[0],
nash_storage[1]. Run 3 calibrated parameters stay fixed — DA only corrects
states.

Headline result

USGS gauge 03463300, 21 sub-catchments, Hurricane Helene test year:

Metric Run 3 (no DA) DA v2 Vrugt R (production) Δ
Mean Test KGE 0.692 0.817 +0.125
Best 0.825 0.874 +0.049
Worst 0.500 0.699 +0.199
Best–Worst spread 0.325 0.175 tighter
Catchments improving 21 / 21

See da_methods/da_v2_vrugt_kge_table.png for the per-catchment table and
da_methods/helene_da_v2_vrugt_vs_run3_grid.png for the Helene timeseries
grid across all 21 catchments.

Files added

Path Purpose
da_methods/calibrate_catchment_cfe_da_v2.py Main script (DA v2 production)
da_methods/plot_da_v2_vrugt_helene_grid.py Generates the Helene grid PNG
da_methods/helene_da_v2_vrugt_vs_run3_grid.png 3×7 Helene grid figure
da_methods/da_v2_vrugt_kge_table.png Per-catchment KGE comparison table

Math, in brief

Test-loop true EnKF

Each hour:

  1. Perturb forcing per member: precip × Lognormal(μ = −σ²/2, σ = 0.15),
    PET × N(1, 0.10).
  2. Run each of 20 CFE members forward 1 hr → 20 forecast Q_sim values and
    20 state vectors.
  3. Compute ensemble covariances from the spread:
    • Pyy = var(Q_ensemble)
    • Pxy_state = cov(state_ensemble, Q_ensemble) for each of 4 states
  4. Per-state Kalman gain: K_state = Pxy_state / (Pyy + R(t))
  5. Perturb the observation N times: obs_i = Q_obs + sqrt(R) × N(0,1)
  6. Update each member: state_i += K_state × (obs_i − Q_sim_i) with the
    mass-conserving cascade applied.
  7. Inject per-state process noise to keep ensemble spread alive over the
    13-month run.
  8. Record the ensemble-mean Q_sim as the simulated discharge for that hour.

Vrugt 2005 heteroscedastic R, scaled by kriging variance

R(t) = (α × y_obs(t))² + scale × σ²_krig(t)
α = 0.10 (Vrugt default; 10% relative error)
scale = 0.001

The flow-magnitude term keeps R small at low flow (so DA fires) and larger
at peaks (where obs is also more uncertain). The kriging variance brings
per-hour catchment-specific info in, scaled so it does not dominate.

For this basin: at typical low flow (y ≈ 0.2 mm/h, σ²_krig ≈ 4),
R ≈ 0.0044; at Helene peak (y ≈ 22 mm/h), R ≈ 4.8.

Mass-conserving cascade (per member)

When DA's correction would push a state past its bound, the residual
cascades along CFE's physical flow direction instead of being silently
clipped:

  • Positive corrections (water added): soil full → Nash[0]; GW full → Nash[1]
  • Negative corrections (water removed): Nash[1] < 0 → Nash[0] → soil → GW → loss (logged in mm)

Per-state process noise (per timestep)

Per-hour multiplicative noise injected on each state, calibrated to each
state's physical response time:

State σ / hr
soil 0.002 (0.2%)
GW 0.0015 (0.15%)
Nash[0], Nash[1] 0.005 + small additive floor

Matches NWC-CUAHSI reference (bmi_cfe_perturb_ens.py). Prevents the
ensemble-collapse failure mode that textbook EnKF references warn about.

Sequential-filter convention

Recorded Q_sim(t) is the pre-analysis forecast — computed before this
hour's DA touches the state. DA's effect on the recorded series
propagates from t+1 onward via the analyzed states. Standard sequential
filter verification — comparing Q_sim(t) to Q_obs(t) is not circular.
Documented inline in the test loop.

CLI flags reference

Flag Default Purpose
--enkf-enabled off Turn DA on
--enkf-members 20 20 Ensemble size (≥ 2 required)
--enkf-obs-error-std 0.05 0.05 mm/h Fallback obs std (used when no variance and --no-vrugt-r)
--no-vrugt-r off Disable Vrugt R (revert to raw kriging variance)
--vrugt-alpha 0.10 0.10 Vrugt relative-error fraction
--vrugt-scale 0.001 0.001 Vrugt scaling on kriging variance

Sample run

python3 da_methods/calibrate_catchment_cfe_da_v2.py \
  --cat-id cat-1016300 \
  --forcing-dir  <NWM retro per-catchment CSV dir> \
  --obs-dir      <kriging dir with qkrig_variance column> \
  --cfe-dir      <cfe_py> \
  --config-file  <CFE BMI config JSON> \
  --param-bounds <CFE parameter bounds JSON> \
  --out-dir      <output dir> \
  --test-forcing-dir1 <NWM operational 2023-Feb2024> \
  --test-forcing-dir2 <NWM operational Feb2024-Sep2025> \
  --enkf-enabled --enkf-members 20
Pre-stage <cat-id>_best_params.json (from the calibrate-cfe workflow)
into the out-dir before running. Each catchment runs 20 CFE instances
simultaneously; recommend batches of 5 catchments at a time if running
all 21.

## Pipeline overview (4-step)

This PR covers the full Qkrig DA pipeline organised into four steps:

| Step | Folder | What it does |
|---|---|---|
| 1 | `1_calibrate/` | CFE calibration with Qkrig obs, all 21 catchments |
| 2 | `2_assimilation/` | DA + 600-member crossed ensemble (30 forcing × 20 hydro-state) |
| 3 | `3_routing/` | t-route Muskingum-Cunge to gauge 03463300 (600-member + lead-time) |
| 4 | `4_evaluation/` | 4a lead-time decay · 4b 600-member ensemble · 4c reconstructed timeseries |

### Evaluation results (gauge 03463300, Helene test year)

| Metric | Value |
|---|---|
| DA NSE vs USGS (full window) | 0.437 vs OL 0.318 (+0.12) |
| DA NSE vs USGS (Helene zoom) | 0.339 vs OL 0.198 (+0.14) |
| Vrugt KGE vs Qkrig (routed)  | 0.869 vs constant-R 0.789 |
| 600-member p95 peak           | 755 m³/s (USGS truth: 1886 m³/s) |

---

Hourly forward EnKF that nudges soil moisture and groundwater
storage from per-catchment Qkrig observations. Reads Run 3 best
params from best_params.json, runs CFE through spinup + test
period, applies DA every hour during test.

State variables updated:
  - soil_reservoir storage_m (via BMI: SOIL_CONCEPTUAL_STORAGE)
  - gw_reservoir storage_m (direct attribute access)

Kalman gain K = P_f / (P_f + R), with P_f = (0.3 * Q_sim)^2
heuristic and R from kriging variance (defaults to obs_error_std^2
when variance column absent). Increment split 60% soil / 40% GW,
clipped to [0, storage_max_m]. NaN obs are skipped.

Usage: python3 calibrate_catchment_cfe_da_v2.py --cat-id <id> \
    --enkf-enabled --test-only ...
@sonalivyascse19-stack
sonalivyascse19-stack marked this pull request as draft May 14, 2026 14:57
Update Nash cascade reservoirs (nash_storage[0] and nash_storage[1])
in addition to soil and groundwater. Splits the total Kalman-gain-
weighted increment as:

  soil    : 30%
  GW      : 15%
  Nash[0] : 20%
  Nash[1] : 35%

Nash[1] gets the heaviest weight because it feeds streamflow next hour,
giving DA the fastest leverage on Q during rising-limb events. Nash
buckets clipped to [0, +inf) since they have no fixed storage_max.

Verified bmi_cfe.py holds nash_storage as a numpy ndarray and passes
self by reference into run_cfe(), so in-place writes propagate.
Test loop (run_testing_period) now runs N=20 parallel CFE members with
multiplicatively perturbed precipitation (15%) and PET (10%) per member,
plus 5% initial state perturbation. At each hour the ensemble of forecast
discharges produces Pyy and the state-to-Q cross-covariances; one Kalman
gain per state is derived from the ensemble (Burgers/Evensen 1998), the
observation is perturbed N times, and every member is updated
independently. The output time series is the ensemble mean. This replaces
the 30/15/20/35 hardcoded split and the (0.3 * Q_sim)^2 forecast-variance
heuristic with data-driven covariance.

Calibration loop keeps the single-trajectory heuristic update via the
renamed update_states_single() helper, so DDS cost is unchanged.

Mass-conserving cascade preserved per member:
  positive : soil -> Nash[0], GW -> Nash[1]
  negative : Nash[1] -> Nash[0] -> soil -> GW -> loss (logged in mm)

New EnKFAssimilator constructor args: precip_perturb_frac, pet_perturb_frac,
init_state_perturb_frac, rng_seed. Diagnostic line at end of test run shows
N, mean innovation, average Pyy, per-state mean Kalman gains, and total
mass lost at GW underflow.
Old docstring described an EnKF that was not actually implemented (no
ensemble), described a single state-update path, and used the wrong
script name in the usage example. The new text accurately states:

  - the calibration loop uses a single-trajectory heuristic
  - the test loop uses a true stochastic EnKF over N ensemble members
  - 4 CFE states are updated with a mass-conserving cascade
  - all three obs CSV column layouts are auto-handled
  - the production usage example points at the right paths

Removed boilerplate "literature improvements" numbers in favor of the
measured result on this basin (mean Test KGE 0.692 -> 0.823, 21
catchments, Helene year).
The recorded Q_sim at time t is the pre-analysis forecast (one-step-ahead
under the most recent analysis). DA's effect on the series propagates
forward through carried states, not by overwriting Q_sim(t). This is the
standard sequential-filter verification setup; adding a comment so future
readers don't mistake the order of operations for a correctness issue.
@sonalivyascse19-stack sonalivyascse19-stack changed the title Add DA v2 EnKF script for per-catchment CFE state assimilation Per-catchment CFE DA: 4-state true EnKF with mass-conserving cascade May 15, 2026
Two textbook-EnKF upgrades inspired by NWC-CUAHSI/data_assimilation_with_bmi
example_run_da_CFE/bmi_cfe_perturb_ens.py and Clark 2008 / Renard 2010 hydrology
DA literature.

1. perturb_forcing(): precip is now lognormal(mu=-sigma^2/2, sigma) so the
   multiplier has mean 1 and is strictly non-negative. PET stays Gaussian.
   Lognormal is the literature-standard distribution for precip noise; it
   matches the heavy-tailed empirical distribution of precip errors and
   avoids negative-precip issues that Gaussian × non-negative can produce.

2. New add_process_noise(models): injects small multiplicative noise on
   soil, GW, Nash[0], Nash[1] every hour. Default sigma = 0.005 (0.5%/hr).
   Nash buckets get an additional small additive floor so noise survives
   when nash_storage starts at 0. Called inside both the spinup loop and
   the test loop after each model.update()/EnKF analysis. This is the Q
   matrix / model-error term that classical Kalman and EnKF formulations
   require. Without it, the analysis collapses spread over long runs and
   Kalman gains for slow-moving states (Nash) drop to ~0 — which is the
   exact behavior we observed in the previous run.

New constructor arg: process_noise_frac (default 0.005). Set to 0 to
disable. Docstrings updated accordingly.
Diagnostic on the previous run (sigma=0.005 across all states) showed that:
  - Nash gains jumped from ~1e-87 to ~1e-4 (fix worked)
  - Soil/GW gains also went up significantly
  - BUT Test KGE dropped 0.882 -> 0.835 and mass lost at GW underflow
    jumped 8.5x (9.9 -> 84.2 mm)

Root cause: 0.5%/hr is too aggressive for slow-evolving soil/GW. Over
9000 hours, soil values do a random walk with cumulative std ~47% of
nominal — way more than physics warrants. GW underflow becomes frequent.

Fix: per-state sigma calibrated to physical response time, matching
NWC-CUAHSI/data_assimilation_with_bmi reference implementation:
  soil  : 0.002 (0.2%)   — slow-evolving, small noise sufficient
  GW    : 0.0015 (0.15%) — slowest state
  Nash  : 0.005 (0.5%)   — needs more; spread doesn't develop naturally

Nash still gets the additive floor so noise survives at zero.

Old single knob 'process_noise_frac' removed; replaced by three per-state
constructor args. Set any to 0 to disable that state's process noise.
Per Dr Frame's suggestion on the Slack thread: use Vrugt et al. 2005 (SODA)
heteroscedastic observation error formulation, scaled by the kriging variance,
so that DA can fire at low flow without being dominated by the large raw
kriging variance values in Kunal's obs files.

  R(t) = (alpha * y_obs(t))^2 + scale * sigma^2_krig(t)

Two terms:
  - (alpha * y_obs)^2  : Vrugt flow-magnitude scaling, alpha=0.10 default
  - scale * sigma^2    : per-hour kriging contribution, scale=0.001 default

Numbers for this basin at typical y_obs ~ 0.2 mm/h, sigma^2_krig ~ 4:
  (0.1 * 0.2)^2 + 0.001 * 4 = 4e-4 + 4e-3 = 0.0044
vs raw kriging variance = 4 (which collapses K to ~0).

New CLI flags:
  --use-vrugt-r    : enable Vrugt R (default off)
  --vrugt-alpha    : relative-error fraction (default 0.10)
  --vrugt-scale    : kriging-variance scaling (default 0.001)

Pre-computed at obs-load time and stored in self.obs_var_dict, so both
DA paths (test loop true EnKF and cal loop heuristic) automatically use
the new R when --use-vrugt-r is enabled. Default OFF preserves prior
runs' behavior exactly.
After comparing four configurations (no PN/single R, per-state PN/raw R,
per-state PN/Vrugt R) across all 21 catchments at gauge 03463300, the
Vrugt R configuration is shipped as the production default:

  Mean Test KGE  : 0.817 (Vrugt) vs 0.823 (raw R)
  Best-worst spread : 0.175 (Vrugt) vs 0.220 (raw R) - much tighter
  cat-1016306 (the persistent regressor) : 0.66 -> 0.74 - recovered

Mean is 0.006 lower but the distribution is meaningfully more uniform,
and the worst catchment finally beats Run 3 baseline. The Vrugt
formulation is also the more defensible scientific story (heteroscedastic
R, kriging variance actually used, matches Dr Frame's Slack suggestion).

Changes:
  - Class default: use_vrugt_r=True
  - CLI flag flipped: --use-vrugt-r -> --no-vrugt-r (default ON; flag to disable)
  - Top docstring + USAGE example updated; production obs dir is now
    catchment_ts_03463300_with_variance so kriging variance enters R(t)
  - All fallback defaults inside EnKFAssimilator() construction sites
    set to True.
plot_da_v2_vrugt_helene_grid.py — generates a 3x7 grid comparing Run 3
(no DA) vs DA v2 (true EnKF + Vrugt R, N=20) vs Qkrig observations for
all 21 sub-catchments during the Hurricane Helene week (Sep 20 - Oct 5,
2024). Per-subplot KGE is displayed in the legend. Same template as the
prior DA v3/v4 grid plots.

da_v2_vrugt_kge_comparison.md — per-catchment KGE table for the
production DA v2 Vrugt R configuration vs Run 3 baseline. Mean Test KGE
improves from 0.692 to 0.817 (+0.125). 20/21 catchments improve;
cat-1016306 is the lone regressor (-0.033). Spread tightens from
0.325 to 0.175.
Replaces da_v2_vrugt_kge_comparison.md (which was markdown) with an
actual rendered table image.

helene_da_v2_vrugt_vs_run3_grid.png — 3x7 grid of all 21 sub-catchments
during Hurricane Helene week, comparing Run 3 baseline (thick blue),
DA v2 production config (thin red), and Qkrig observations (black).
Per-subplot KGE shown in the legend. Generated by
plot_da_v2_vrugt_helene_grid.py.

da_v2_vrugt_kge_table.png — color-coded per-catchment KGE comparison
table. Green = improvement, yellow = neutral, red = regression. Mean
row highlighted. 20/21 catchments improve; cat-1016306 is the lone
regressor (-0.033). Mean Test KGE goes from 0.692 (Run 3) to 0.817
(DA v2 Vrugt R).
@sonalivyascse19-stack sonalivyascse19-stack changed the title Per-catchment CFE DA: 4-state true EnKF with mass-conserving cascade Per-catchment CFE DA: true EnKF (N=20) + Vrugt R + mass-conserving cascade May 15, 2026
CFE parameter calibration (DDS over the 2020-2022 retro period) is
performed separately by the calibrate-cfe workflow; this script's job
is to take pre-calibrated <cat-id>_best_params.json and run the test
period with optional EnKF DA on top. The dual-purpose framing was
generating two different DA implementations in one file, which was
confusing and unused in production (we always ran --test-only).

Removed (about 265 lines):
  - SpotpySetup class (DDS spotpy wrapper + cal-loop simulation)
  - EnKFAssimilator.update_states_single (heuristic 30/15/20/35 path)
  - load_obs_for_period (only used by SpotpySetup.__init__)
  - DDS sampling block in main()
  - import spotpy
  - --N CLI flag (DDS iterations)
  - --test-only CLI flag (now implicit; the script only does test)

Added:
  - Local _kge_score / _nse_score functions (replace
    spotpy.objectivefunctions.kge / .nashsutcliffe in run_testing_period)

Updated:
  - Top docstring drops the 'two DA paths' framing
  - EnKFAssimilator class docstring documents only the ensemble path
  - USAGE example no longer carries --test-only
  - main() now errors cleanly if best_params.json is missing,
    pointing the user at the calibrate-cfe workflow

CLI changes:
  - Removed: --N, --test-only
  - All other flags unchanged. --param-bounds is kept (still required)
    but the file is no longer read in this script.

Net script length: 1130 lines -> 866 lines.
Same 3x7 grid as plot_da_v2_vrugt_helene_grid.py (Run 3 vs DA v2 Vrugt R
vs Qkrig obs), but with a tighter 4-day window around the Helene peak so
the timing and amplitude detail at the rising/falling limbs is visible.
Daily x-ticks plus 6-hour minor ticks.
3x7 grid of all 21 sub-catchments showing the 4-day Helene peak window.
Same Run 3 vs DA v2 Vrugt R vs Qkrig comparison as the 16-day grid, but
tightened to make peak timing and amplitude visible.

Generated by plot_da_v2_vrugt_helene_zoomed_grid.py.
@jmframe jmframe self-assigned this May 15, 2026
@jmframe
jmframe requested a review from AM0hi May 15, 2026 21:49
@sumabattula

Copy link
Copy Markdown

I see that the per catchment NSE/KGE is improving. but that's only with respect to QKrig. how about the peak at the gauge, during helene? Do any of the ensemble members capture the peak with respect to obs Q.?

…ti plot)

run_perturbation_sensitivity.py — runs 20 CFE members through the test
period with ONLY ONE perturbation source active, no DA, no spinup.
Three modes:
  init     : initial state perturbation only (~5% at t=0)
  forcing  : forcing perturbation only (lognormal precip, Gaussian PET)
  process  : per-hour process noise on states only

Each run writes a per-member CSV with 21 columns (date + 20 member Q values)
so downstream plotting can show all trajectories instead of just the mean.
Reuses the production script's calibrated best_params.json. Standalone — does
not touch the production calibrate_catchment_cfe_da_v2.py.

plot_perturbation_sensitivity_spaghetti.py — reads the three per-source CSVs
per catchment and plots colored ensemble bundles on the Helene 4-day peak
window (Sep 24-28). Produces two figures:
  helene_sensitivity_spaghetti_main.png      - 3-catchment subset (cat-1016311,
    cat-1016300, cat-1016302; worst/median/best Run 3 KGE)
  helene_sensitivity_spaghetti_appendix.png  - full 3 x 7 grid of all 21
    sub-catchments

Black Qkrig observation overlaid for reference.
plot_perturbation_sensitivity_spread.py — cleaner alternative to the
spaghetti plot. For each catchment and each of the three sensitivity
sub-experiments, computes the hourly std-dev across the 20 members and
plots all three curves on the same axes. Directly answers 'which source
contributes most to spread' without the visual clutter of overlapping
member bundles.

run_production_per_member.py — production-equivalent run for ONE
catchment (init + forcing + process + DA all active, Vrugt R) but saves
per-member streamflow trajectories instead of only the ensemble mean.
Imports EnKFAssimilator from calibrate_catchment_cfe_da_v2.py to
guarantee identical DA math. Skips spinup to match the sensitivity-run
setup, so per-member comparisons against the sensitivity CSVs are
apples-to-apples.

plot_per_member_factor_decomposition.py — 20-panel figure for one
catchment. Each panel shows ONE ensemble member's actual production
forecast (purple thick) decomposed against its trajectory under each
isolated perturbation source (red = init only, blue = forcing only,
green = process noise only), plus the Qkrig observation. Helene 4-day
peak window. First-cut visualization (member identity is column-name
only across the four CSVs; not matched-seed).
Plots all 20 production-per-member streamflow trajectories overlaid on
one time-series panel, styled after the Battula et al. ensemble-forecast
figure (50/100/200 km variogram-range panels):
  - Each member as a thin turbo-colored line, labeled with index + peak
  - Hurricane Helene window (Sep 26 12:00 - Sep 28 00:00) highlighted as
    a pink shaded vertical band
  - Qkrig observation overlaid as a thick black line on top
  - Plot window Sep 20 - Oct 5 so the storm sits in context
  - Two outputs: linear-y and log-y versions

Consumes the production-per-member CSV produced by
run_production_per_member.py. No GPU re-run required if that CSV already
exists for the target catchment.
…stic plot

run_production_per_member.py — now saves three additional outputs per run:
  - <cat>_production_per_member_precip.csv (perturbed precip per member)
  - <cat>_production_per_member_pet.csv    (perturbed PET per member)
  - <cat>_production_per_member_initial_states.json (4 states per member at t=0)

plot_per_member_input_output_diagnostic.py — new plot script.
Layout: 20 rows x 4 columns. Each row is one ensemble member.
  col 1: perturbed precipitation (bars)
  col 2: perturbed PET (line)
  col 3: simulated streamflow + Qkrig obs
  col 4: initial-state values as annotation
Helene 4-day peak window (Sep 24-28, 2024).

Answers 'why did THIS member produce THIS forecast' by tracing inputs
to output, rather than running counterfactual sub-experiments.
Each member is now ONE combined panel instead of 4 separate columns.
Internal layout per panel:
  top    : perturbed precip (blue bars, left axis) + perturbed PET
           (orange line on twinx, separate scale)
  bottom : simulated Q (purple) vs Qkrig obs (black dashed), shared x-axis
  title  : initial-state values for that member (soil, GW, Nash[0], Nash[1])

Grid: 5 rows x 4 cols = 20 panels. All panels share the same y-axis ranges
across precip, PET, and Q for direct visual comparison between members.

Each input/output stream is on its own y-axis with a label and matching
color (P blue, PET orange, Q purple) so even at thumbnail size you can
read which trace is which.
Two layout tweaks to the diagnostic plot:
  - Split each panel title into two lines (member name on top, initial-state
    values below) so they no longer collide with the adjacent panel's title.
  - Bump hspace inside each member cell (precip+PET above, Q below) from
    0.05 to 0.20 so the bottom Q-axis ticks no longer brush the top of the
    next subpanel down.
  - Slightly increase outer GridSpec hspace to accommodate the taller
    2-line titles.
For one catchment, plot three category-organized ensemble groups:
  1. Initial states only       (red)
  2. Meteorological forcings   (blue)
  3. Hydrological states only  (green)

Each category is rendered as a 10th-90th percentile shaded band across
its 20 members, plus a thicker median line in the same color. Qkrig
observation overlaid in black. Hurricane Helene peak window shaded in
pink. Paper-style figure inspired by Battula et al. ensemble-forecast
plot.

Consumes the existing per-source sensitivity CSVs - no new GPU run
needed. Outputs both linear-y and log-y versions of the figure.
…nvelope

Production-magnitude perturbations produce narrow ensemble spread at the
Helene peak. The 10-90 percentile bands were too tight to be visually
distinguishable. Min-max envelope across the 20 members is the widest
possible band from the existing data and reveals the spread structure
more clearly, especially for the forcing-only category which has the
biggest dispersion at the peak.

Sensitive to outliers, but that is part of the story: a few members
hitting bigger perturbation factors push the envelope up at the peak.
Restructures da_methods/ into subdirectories matching the Qkrig DA repo
design: 1_calibrate, 2_assimilation, 3_routing, 4_evaluation.

Adds new scripts for the full Hurricane Helene probabilistic forecasting
pipeline: run_perturbation_da_on.py (2a/2b DA arms), run_crossed_ensemble.py
(600-member 30x20 design), run_route_crossed_ensemble.py (t-route routing
of ensemble to gauge), batch runners for all 21 catchments, and evaluation
plots for Vrugt vs no-Vrugt comparison and routed ensemble vs USGS gauge.

Also adds da_methods/README.md documenting the end-to-end data flow and
run order from calibration through gauge evaluation.
…ensemble scripts

- Add route_leadtime_f{1,2,4}.sh: T-route routing of 18-hr lead-time forecast
  CSVs to gauge parquets, required for gauge-level error decay plots
- Add plot_forecast_error_fixed_target.py and plot_forecast_error_per_init.py
  to F1 and F5 (already present in F2/F4); update all four run_4a scripts to
  call them when routed parquets exist
- Add plot_routed_ensemble_vs_usgs.py, plot_perturbation_category_shaded.py,
  run_4b_f2.sh to F2 (4b was sparse vs F1/F4)
- Add plot_perturbation_category_shaded.py, run_4b_crossed_f5.sh to F5
Copies run_perturbation_sensitivity.py (DA-off, 20-member ensemble, 3 sources:
init/forcing/process) from F1 into F2 and F5. Adds matching batch scripts
batch_run_sensitivity_f2.sh and batch_run_sensitivity_f5.sh pointing at each
folder's OUT_DIR and correct OBS_DIR (gapfilled for F2, rekrig for F5).
… F4/F5 4b scripts

route_ensemble_f1/f2/f4.sh route all 600 crossed-ensemble members through
Muskingum-Cunge to produce routed_crossed_ensemble.parquet (same pattern as
existing route_ensemble_f5.sh). Fixes wrong _routed suffix in F1_DIR path
in run_4b_f4.sh and run_4b_f5.sh.
F4: add run_perturbation_sensitivity.py and batch_run_sensitivity_f4.sh.
F5: add new_EnKF.py, input_enkf_new.json, run_crossed_ensemble.py,
run_perturbation_da_on.py (copied from F4 — same --direct-variance logic,
different OBS_DIR pointing at rekrig obs), batch_run_crossed_f5.sh,
batch_run_leadtime_f5.sh, batch_run_da_on_all_cats.sh, route_leadtime_f5.sh.
All four folders now have complete spec coverage for items 2 and 3.
… forcing arm

F1: helene_sensitivity_spaghetti/spread main+appendix (from run_4b_f1.sh).
F2: helene_ensemble_twopanel, helene_ensemble_vs_usgs, routed_ensemble_vs_usgs
    full+peak (from run_4b_f2.sh), cat-1016300_2a_forcing_arm_helene.
F4: cat-1016300_2a_forcing_arm_helene.
F5: cat-1016300_2a_forcing_arm_helene.
All four folders now have 2a+2b arm, 4b ensemble, and spec item 4b figures.
…ython scripts

All 4 experiment folders (F1 vrugt, F2 fixed-R, F3 dyn-vrugt-seeded, F4 dyn-variance):
- 3_routing/: route_det, route_leadtime, route_ensemble scripts (12 new scripts)
- 4a/: run_4a_f*.sh for all 4 folders
- 4b/: run_4b_f*.sh, run_4b_crossed_f*.sh, plot_routed_ensemble_vs_usgs.py for all 4 folders
- 4c/: run_4c_f1.sh, run_4c_f2.sh (F3/F4 already had these)
- 2_assimilation/: run_lead_time_forecast_sweep.py (F1), run_perturbation_sensitivity.py
  and run_production_per_member.py (F2/F3/F4)
- batch_run_f5_20pct.sh: production DA run (EnKF N=20, --no-vrugt-r)
  using re-kriged variance obs and held-out calibration params
- batch_run_f5_analysis_20pct.sh: perturbation arms (Stage A) and
  lead-time forecast sweep (Stage B) for uncertainty quantification
- route_det_f5_20pct.sh: deterministic T-route routing to gauge 03463300
- batch_run_openloop_20pct.sh: open-loop baseline (no DA) for 20% holdout
- route_openloop_20pct.sh: T-route routing for open-loop baseline
- compare_all_folders.py: updated to include F5 in metrics table and plots

Routed results: Full KGE=+0.317, NSE=+0.556; Helene KGE=+0.286, NSE=+0.461
Adds arm plots (2a, 2b, 2ab), lead time decay, open-loop comparison,
and multi-folder view plots for the F5 re-kriged σ² formulation.
Fixes F2 routing script to write output to a separate directory.
…holdout)

- update view1_full_log_f2_vs_ol.png: title now reads
  'F5 (best NSE) and F2 (best KGE) vs Open loop'
- add folder5_rekrig_variance_direct/4_evaluation/4a_error_decay/:
    plot_lead_time_nse_gauge_f5.py  — NSE vs lead time (pooled + regime split)
    run_4a_f5.sh                    — shell driver for the server
- lead_time_nse_gauge_f5_pooled.png: pooled NSE vs lead hour (DA vs open loop)
- lead_time_nse_gauge_f5_by_regime.png: NSE by regime (Helene / storm / low-flow)
- fix int(np.where(...)[0]) → int(np.where(...)[0][0]) to silence DeprecationWarning
Per-init-time coloured fan (min-max shading + median) for Sep 24-30 2024,
grand mean, open-loop grand median, and USGS obs at routed focal outlet.
Forecast window is 18 hours (not 120); reverts the variable to
match the intended short-range evaluation horizon for this holdout.
With FORECAST_LEAD_HOURS=18, ticks every 1h (0..18) give a
readable x-axis; the previous 12h spacing left only 0 and 12.
Plots DA ensemble mean vs Qkrig obs at catchment level (mm/h)
before routing, so DA skill can be assessed independently of
T-Route. Full period + Helene zoom panels, NSE in title.
Plots T-Route output (m³/s) vs USGS obs at gauge 03463300.
Full period + Helene zoom, NSE and KGE in title.
Figure was generated using wrong obs source — needs to be
regenerated with 20% holdout kriging obs directory.
Generated using 20% holdout kriging obs directory
(catchment_ts_03463300_dynamic_variance_rekrig), replacing
the previously removed incorrect version.
Routes 21-catchment hydro-state arm CSVs (mm/h) through T-Route
Muskingum-Cunge to gauge 03463300 (m3/s) for each Helene init day,
then overlays open-loop grand median from parquet and USGS obs.
Saves cat-1016300_2b_hydro_arm_helene.png with open loop dashed line.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants